/*
    Copyright(C) 2010 Ying-Chun Liu(PaulLiu). All rights reserved.

    This program is free software; you can redistribute it and/or modify
    it under the terms of the GNU General Public License as published by
    the Free Software Foundation; either version 2 of the License, or
    (at your option) any later version.

    This program is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    GNU General Public License for more details.

    You should have received a copy of the GNU General Public License
    along with this program; if not, write to the Free Software
    Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
*/

package org.debian.paulliu.plft;

import java.io.*;
import java.util.*;
import java.net.*;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.SSLSocketFactory;
import java.util.logging.*;
import org.ice4j.pseudotcp.PseudoTcpSocketFactory;
import org.ice4j.pseudotcp.PseudoTcpSocket;
import org.apache.commons.codec.binary.Base64;

public class Session implements javax.net.ssl.HandshakeCompletedListener {

    DatagramSocket localSocket;
    String remoteAddress;
    javax.net.ssl.SSLSocket remoteSocket;
    private org.debian.paulliu.net.StunClient stunClient;
    private Thread sessionInputThreadInstance = null;
    private SessionOutputThread sessionOutputThreadInstance = null;
    private Vector<SessionTextEventListener> textListeners;
    private Vector<Vector<SessionChannelEventListener>> channelListeners;
    private FileChannel fileChannel = null;

    class SessionInputThread extends Thread {
	private InputStream in;

	public SessionInputThread() {
	    if (remoteSocket == null) {
		PLFT.log(Level.SEVERE, "Cannot get input stream due to remoteSOcket is null");
		return;
	    }
	    try {
		in = remoteSocket.getInputStream();
	    } catch (Exception e) {
		PLFT.log(Level.SEVERE, "Cannot get input stream: "+ e.toString());
		e.printStackTrace();
	    }
	}

	public void run() {
	    int a;
	    int state;
	    byte[] header=new byte[4];
	    header[0]=(byte)(0x20);
	    header[1]=(byte)(0xAA);
	    header[2]=(byte)(0xB1);
	    header[3]=(byte)(0xE5);
	    byte[] buf=null;
	    int len=0;

	    state=0;
	    try {
		run_loop_1:
		while (in != null && !remoteSocket.isClosed() && !remoteSocket.isInputShutdown()) {
		    switch (state) {
		    case 0:
		    case 1:
		    case 2:
		    case 3:
			a = in.read();
			if (a==-1) {
			    break run_loop_1;
			}
			if (((byte)(a & 0xff))== header[state]) {
			    state++;
			} else {
			    state=0;
			}
			break;
		    case 4:
			buf = new byte[4];
			for (int len_read=4; len_read>0; ) {
			    int r;
			    r = in.read(buf, buf.length-len_read, len_read);
			    if (r<0) {
				break run_loop_1;
			    }
			    len_read -= r;
			}
			len = (buf[0] & 0xff) * 256 * 256 * 256
			    + (buf[1] & 0xff) * 256 * 256
			    + (buf[2] & 0xff) * 256
			    + (buf[3] & 0xff);
			state=5;
			PLFT.log(Level.INFO, "Recv packet. length="+Integer.toString(len));
			break;
		    case 5:
			buf = new byte[len];
			for (int len_read=len; len_read>0; ) {
			    int r;
			    r = in.read(buf, buf.length-len_read, len_read);
			    if (r<0) {
				break run_loop_1;
			    }
			    len_read -= r;
			}
			recvPacketCallback(buf);
			state=0;
			break;
		    default:
			break;
		    }
		}
	    } catch (Exception e) {
		PLFT.log(Level.SEVERE, "Receiving thread exits: "+ e.toString());
	    }
	} 
    }

    class SessionOutputThread extends Thread {
	private OutputStream out;
	private java.util.concurrent.LinkedBlockingQueue<byte[]> outQueue=null;

	public SessionOutputThread() {
	    if (remoteSocket == null) {
		PLFT.log(Level.SEVERE, "Cannot get output stream due to remoteSOcket is null");
		return;
	    }
	    try {
		out = remoteSocket.getOutputStream();
	    } catch (Exception e) {
		PLFT.log(Level.SEVERE, "Cannot get output stream: "+ e.toString());
		e.printStackTrace();
	    }
	    outQueue = new java.util.concurrent.LinkedBlockingQueue<byte[]> ();
	}

	public java.util.concurrent.LinkedBlockingQueue<byte[]> getOutQueue() {
	    return outQueue;
	}

	public void sendPacket(byte[] data) {
	    if (data != null && data.length > 0) {
		try {
		    outQueue.put(data);
		} catch (Exception e) {
		    PLFT.log(Level.SEVERE, "Put data into out queue: "+ e.toString());
		}
	    }
	}

	public void run() {
	    try {
		while (out != null && !remoteSocket.isClosed() && !remoteSocket.isOutputShutdown()) {
		    byte[] data=null;
		    data = outQueue.take();
		    if (data == null || data.length == 0) {
			continue;
		    }
		    byte[] packet;
		    packet = new byte[data.length+8];
		    packet[0]=(byte)(0x20);
		    packet[1]=(byte)(0xAA);
		    packet[2]=(byte)(0xB1);
		    packet[3]=(byte)(0xE5);
		    packet[4]=(byte)((data.length/256/256/256)%256);
		    packet[5]=(byte)((data.length/256/256)%256);
		    packet[6]=(byte)((data.length/256)%256);
		    packet[7]=(byte)((data.length)%256);
		    for (int i=0; i<data.length; i++) {
			packet[i+8] = data[i];
		    }
		    out.write(packet);
		    PLFT.log(Level.INFO, "Send packet. length="+Integer.toString(data.length));
		}
	    } catch (Exception e) {
		PLFT.log(Level.SEVERE, "Receiving thread exits: "+ e.toString());
	    }
	} 
    }

    public Session () {
	this.stunClient = new org.debian.paulliu.net.StunClient(new InetSocketAddress("stun.sipgate.net",3478));
	this.stunClient.perform();
	this.localSocket = stunClient.getSocket();
	this.textListeners = new Vector<SessionTextEventListener> ();
	this.channelListeners = new Vector<Vector<SessionChannelEventListener>> ();
	for (int i=0; i<256 ; i++) {
	    channelListeners.add(new Vector<SessionChannelEventListener>());
	}
    }

    /**
     * return internal stunClient
     */
    org.debian.paulliu.net.StunClient getStunClient() {
	return this.stunClient;
    }

    /**
     * return local public/private address encoded by Base64
     */
    public String getLocalAddressBase64() {
	int i;
	byte[] buf = new byte[12];
	InetSocketAddress socketAddrPublic = new InetSocketAddress(this.stunClient.getMyPublicIP(), this.stunClient.getMyPublicPort());
	InetSocketAddress socketAddrPrivate = new InetSocketAddress(this.stunClient.getMyPrivateIP(), this.stunClient.getMyPrivatePort());
	byte[] buf1 = null;
	buf1 = socketAddrPublic.getAddress().getAddress();
	for (i=0; i<buf1.length; i++) {
	    buf[i] = buf1[i];
	}
	buf1 = new byte[2];
	buf1[0] = (byte)(socketAddrPublic.getPort()/256);
	buf1[1] = (byte)(socketAddrPublic.getPort()%256);
	for (i=0; i<buf1.length; i++) {
	    buf[i+4] = buf1[i];
	}
	buf1 = socketAddrPrivate.getAddress().getAddress();
	for (i=0; i<buf1.length; i++) {
	    buf[i+6] = buf1[i];
	}
	buf1 = new byte[2];
	buf1[0] = (byte)(socketAddrPrivate.getPort()/256);
	buf1[1] = (byte)(socketAddrPrivate.getPort()%256);
	for (i=0; i<buf1.length; i++) {
	    buf[i+10] = buf1[i];
	}

	String ret = org.apache.commons.codec.binary.Base64.encodeBase64String(buf);
	return ret;
    }

    /**
     * compare 2 InetSocketAddr to see who is larger
     * @param addr1 the first InetSocketAddress
     * @param addr2 the second InetSocketAddress
     * @return 1 if addr1>addr2, -1 if addr1<addr2, 0 if equal
     */
    public static int compareInetSocketAddress(InetSocketAddress addr1, InetSocketAddress addr2) {
	if (addr1.getPort() < addr2.getPort()) {
	    return -1;
	} else if (addr1.getPort() > addr2.getPort()) {
	    return 1;
	}
	byte[] rawaddr1 = addr1.getAddress().getAddress();
	byte[] rawaddr2 = addr2.getAddress().getAddress();
	for (int i=0; i < rawaddr1.length && i < rawaddr2.length; i++) {
	    if (rawaddr1[i] < rawaddr2[i]) {
		return -1;
	    } else if (rawaddr1[i] > rawaddr2[i]) {
		return 1;
	    }
	}
	return 0;
    }

    /** 
     * create InetSocketAddress base on Base64 string 
     * @param s the base64 string represents a InetSocketAddress (ip+port). Should be length 8
     */
    public static InetSocketAddress createInetSocketAddressBase64(String s) {
	InetSocketAddress ret=null;
	byte[] buf = null;
	int port=0;
	buf = Base64.decodeBase64(s);
	byte[] buf1 = new byte[4];
	for (int i=0; i<buf1.length && i<buf.length; i++) {
	    buf1[i] = buf[i];
	}
	if (buf.length >= 6) {
	    port = ((int)(buf[4]&0xFF))*256 + ((int)(buf[5]&0xFF));
	}
	try {
	    ret = new InetSocketAddress(InetAddress.getByAddress(buf1), port);
	} catch (Exception e) {
	    PLFT.log(Level.SEVERE, "Cannot create socket addr");
	}
	return ret;
    }

    /** 
     * connect to remote address 
     * @param s the base64 string represents a InetSocketAddress (ip+port) for remote host. Can be length 8 or 16. length 8 is for public ip only. length 16 is public ip + private ip.
     */
    public void connect(String remoteAddress) {
	InetSocketAddress socketAddrPublic = null;
	InetSocketAddress socketAddrPrivate = null;

	this.remoteAddress = remoteAddress;

	if (remoteAddress.length() == 16) {
	    socketAddrPublic = createInetSocketAddressBase64(remoteAddress.substring(0,8));
	    socketAddrPrivate = createInetSocketAddressBase64(remoteAddress.substring(8));
	} else if (remoteAddress.length() == 8) {
	    socketAddrPublic = createInetSocketAddressBase64(remoteAddress);
	}

	/* Use UDP Puncher to punch a hole on NAT */
	InetSocketAddress punchAddr = null;
	PLFT.log(Level.INFO, "UDP punching holes");
	if (socketAddrPrivate != null) {
	    org.debian.paulliu.net.UDPHolePuncher puncher = new org.debian.paulliu.net.UDPHolePuncher(socketAddrPublic, socketAddrPrivate, localSocket);
	    punchAddr = puncher.punch();
	} else {
	    org.debian.paulliu.net.UDPHolePuncher puncher = new org.debian.paulliu.net.UDPHolePuncher(socketAddrPublic, localSocket);
	    punchAddr = puncher.punch();
	}
	PLFT.log(Level.INFO, "UDP punched");
	try {
	    localSocket.connect(punchAddr);
	} catch (Exception e) {
	    PLFT.log(Level.SEVERE, "cannot connect UDP socket");
	}


	/* Create Pseudo TCP on top of UDP */
	org.ice4j.pseudotcp.PseudoTcpSocketFactory pTcpSocketFactory = null;
	org.ice4j.pseudotcp.PseudoTcpSocket pTcpSocket = null;
	org.debian.paulliu.net.PseudoTcpSocket2 pTcpSocket2 = null;
	pTcpSocketFactory = new org.ice4j.pseudotcp.PseudoTcpSocketFactory();
	try {
	    pTcpSocket = pTcpSocketFactory.createSocket(localSocket);
	    pTcpSocket2 = new org.debian.paulliu.net.PseudoTcpSocket2(pTcpSocket);
	} catch (Exception e) {
	    PLFT.log(Level.SEVERE, "Cannot create pseudo TCP socket: "+ e.toString());
	}
	try {
	    pTcpSocket.setConversationID(1234);
	} catch (Exception e) {
	    PLFT.log(Level.SEVERE, "Cannot set pseudo TCP socket convID");
	}
	try {
	    if (Session.compareInetSocketAddress(Session.createInetSocketAddressBase64(getLocalAddressBase64()), socketAddrPublic) < 0) {
		PLFT.log(Level.INFO, "I'm server");
		pTcpSocket.accept(10000);
		pTcpSocket2.setInetAddress(punchAddr.getAddress());
	    } else {
		PLFT.log(Level.INFO, "I'm client");
		pTcpSocket.connect(punchAddr, 10000);
	    }
	} catch (Exception e) {
	    PLFT.log(Level.SEVERE, "Cannot connect to each other on pseudo TCP socket");
	}
	PLFT.log(Level.INFO, "PseudoTCPSocket Option (Nagel algorithm): " + Long.toString(pTcpSocket.getOption(org.ice4j.pseudotcp.Option.OPT_NODELAY)));
	pTcpSocket.setOption(org.ice4j.pseudotcp.Option.OPT_NODELAY,1);
	PLFT.log(Level.INFO, "PseudoTCPSocket Option (Nagel algorithm): " + Long.toString(pTcpSocket.getOption(org.ice4j.pseudotcp.Option.OPT_NODELAY)));
	PLFT.log(Level.INFO, "PseudoTCPSocket Option (Sendbuf size): " + Long.toString(pTcpSocket.getOption(org.ice4j.pseudotcp.Option.OPT_SNDBUF)));
	PLFT.log(Level.INFO, "PseudoTCPSocket Option (Recvbuf size): " + Long.toString(pTcpSocket.getOption(org.ice4j.pseudotcp.Option.OPT_RCVBUF)));

	/* Create SSL on top of Pseudo Tcp */
	SSLSocketFactory sslSocketFactory = null;
	sslSocketFactory = javax.net.ssl.HttpsURLConnection.getDefaultSSLSocketFactory();
	SSLSocket sslSocket = null;
	try {
	    sslSocket = (SSLSocket) sslSocketFactory.createSocket(pTcpSocket2, punchAddr.getAddress().getHostAddress(), punchAddr.getPort(), false);
	    sslSocket.setEnabledCipherSuites(org.debian.paulliu.utils.SSLSocketCipherSuiteChooser.getAnonymousCipherSuites(sslSocket));
	} catch (Exception e) {
	    PLFT.log(Level.SEVERE, "Cannot create SSL socket: " + e.toString());
	}
	this.remoteSocket = sslSocket;
	try {
	    if (Session.compareInetSocketAddress(Session.createInetSocketAddressBase64(getLocalAddressBase64()), socketAddrPublic) < 0) {
		sslSocket.setUseClientMode(false);
		sslSocket.addHandshakeCompletedListener(this);
		sslSocket.setNeedClientAuth(false);
		sslSocket.setWantClientAuth(false);
		PLFT.log(Level.INFO, "I'm server");
	    } else {
		sslSocket.setUseClientMode(true);
		sslSocket.addHandshakeCompletedListener(this);
		PLFT.log(Level.INFO, "I'm client");
	    }
	    PLFT.log(Level.INFO, "SSL handshaking");
	    sslSocket.startHandshake();
	    PLFT.log(Level.INFO, "SSL handshaked");
	    sslSocket.setKeepAlive(true);
	} catch (java.lang.NullPointerException e) {
	    PLFT.log(Level.SEVERE, "Cannot handshake SSL: "+e.toString());
	    e.printStackTrace();
	} catch (javax.net.ssl.SSLException e) {
	    PLFT.log(Level.SEVERE, "Cannot handshake SSL: "+e.toString()+" "+e.getCause());
	    e.printStackTrace();
	} catch (Exception e) {
	    PLFT.log(Level.SEVERE, "Cannot handshake SSL: "+e.toString());
	}

	this.fileChannel = new FileChannel(this);
    }

    /**
     * event handler for SSL handshake complete
     */
    public void handshakeCompleted(javax.net.ssl.HandshakeCompletedEvent event) {
	PLFT.log(Level.INFO, "SSL handshake complete");
	startSessionInputThread();
	startSessionOutputThread();
    }

    public int sendPacket(byte[] data) {
	if (data==null || data.length <= 0) {
	    return -1;
	}
	this.sessionOutputThreadInstance.sendPacket(data);
	return 0;
    }

    /**
     * compare 2 byte[] to see who is larger
     * @param a the first byte[]
     * @param b the second byte[]
     * @return 1 if a>b, -1 if a<b, 0 if equal
     */
    public static int compareByteArray(byte[] a, byte[] b) {
	if (a == b) {
	    return 0;
	}
	if (a == null) {
	    return 1;
	}
	if (b == null) {
	    return -1;
	}
	for (int i=0; i<a.length && i<b.length; i++) {
	    if (a[i] < b[i]) {
		return -1;
	    } else if (a[i] > b[i]) {
		return 1;
	    }
	}
	if (a.length < b.length) {
	    return -1;
	} else if (a.length > b.length) {
	    return 1;
	}
	return 0;
    }

    /**
     * event handler, called when packet received
     */
    private void recvPacketCallback(byte[] data) {
	/* if it is message */
	byte[] message_header = null;
	byte[] channel_header = null;
	try {
	    message_header = new String("MESSAGE ").getBytes("UTF-8");
	    channel_header = new String("CHANNEL ").getBytes("UTF-8");
	} catch (Exception e) {
	    PLFT.log(Level.SEVERE, "not able to use String.getBytes() method: "+e.toString());
	}
	
	if (message_header != null && data.length >= message_header.length) {
	    byte[] data_header = null;
	    data_header = java.util.Arrays.copyOfRange(data, 0, message_header.length);
	    if (Session.compareByteArray(message_header, data_header) == 0) {
		/* message */
		byte[] data_tail = java.util.Arrays.copyOfRange(data, message_header.length, data.length);
		String message = null;
		try {
		    message = new String(data_tail, "UTF-8");
		} catch (Exception e) {
		    PLFT.log(Level.SEVERE, "not able to use String.getBytes() method: "+e.toString());
		}
		processSessionTextEvent(new SessionTextEvent(this, message));
	    }
	} 
	if (channel_header != null && data.length >= channel_header.length) {
	    byte[] data_header=null;
	    data_header = java.util.Arrays.copyOfRange(data, 0, channel_header.length);
	    if (Session.compareByteArray(channel_header, data_header) == 0) {
		/* channel */
		int channelNum=0;
		channelNum = (int)((data[channel_header.length]) & 0xff);
		byte[] data_tail = java.util.Arrays.copyOfRange(data, channel_header.length+1, data.length);
		processSessionChannelEvent(new SessionChannelEvent(this, channelNum, data_tail));
	    }
	}
    
    }

    /**
     * start a thread to listen to the input of the ssl Socket.
     * this thread will call recvPacketCallback when it receives a
     * packet.
     */
    private void startSessionInputThread() {
	if (sessionInputThreadInstance != null) {
	    return;
	}
	sessionInputThreadInstance = new SessionInputThread();
	sessionInputThreadInstance.start();
    }

    /**
     * start a thread to output of the ssl Socket.
     */
    private void startSessionOutputThread() {
	if (sessionOutputThreadInstance != null) {
	    return;
	}
	sessionOutputThreadInstance = new SessionOutputThread();
	sessionOutputThreadInstance.start();
    }

    /**
     * Adds the specified text event listener to receive text events from this text component. If l is null, no exception is thrown and no action is performed.
     * @param l the text event listener
     */
    public void addSessionTextEventListener(SessionTextEventListener l) {
	if (l == null) {
	    return;
	}
	textListeners.add(l);
    }

    /**
     * Adds the specified channel event listener to receive channel events from this channel component. If l is null, no exception is thrown and no action is performed.
     * @param l the channel event listener
     * @param channel the channel number
     */
    public void addSessionChannelEventListener(int channel, SessionChannelEventListener l) {
	if (l == null) {
	    return;
	}
	channelListeners.elementAt(channel).add(l);
    }

    /**
     * Processes text events occurring on this text component by dispatching them to any registered SessionTextEventListener objects. 
     * @param e the text event
     */
    protected void processSessionTextEvent (SessionTextEvent e)
    {
	for (int i=0; i<textListeners.size(); i++) {
	    textListeners.elementAt(i).textReceived(e);
	}
    }

    /**
     * Processes channel events occurring on this channel component by dispatching them to any registered SessionChannelEventListener objects. 
     * @param e the channel event
     */
    protected void processSessionChannelEvent (SessionChannelEvent e)
    {
	int channel;
	channel = e.getChannel();
	if (channel >= channelListeners.size()) {
	    return;
	}
	for (int i=0; i<channelListeners.elementAt(channel).size(); i++) {
	    Vector<SessionChannelEventListener> v=null;
	    v = channelListeners.elementAt(channel);
	    v.elementAt(i).dataReceived(e);
	}
    }

    public FileChannel getFileChannel() {
	return this.fileChannel;
    }

}
